package util.oma;

import static java.util.Arrays.asList;
import static java.util.Collections.disjoint;
import static util.oma.Modifier.APPROXIMATE_USING_SETTERS;
import static util.oma.Modifier.TRANSIENT;

import java.lang.reflect.AccessibleObject;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import util.ReflectUtils;

/**
 * Map one object, marked as {@link Approximate}, from others (listed in {@link Approximate} value).
 * 
 * @author Mykhaylo Adamovych
 */
public class Mapper {
    private static final String SKIP_SIGN = ";";
    /**
     * to class - > from class - > to member -> from path
     */
    private static final Map<Class<?>, Map<Class<?>, Map<AccessibleObject, String>>> contract = new ConcurrentHashMap<Class<?>, Map<Class<?>, Map<AccessibleObject, String>>>();
    /**
     * to member -> from member
     */
    private static final Map<AccessibleObject, AccessibleObject> getters = new ConcurrentHashMap<AccessibleObject, AccessibleObject>();
    private static final Map<AccessibleObject, Boolean> nullable = new ConcurrentHashMap<AccessibleObject, Boolean>();
    private static final Mapper defaultMapper = new Mapper();
    
    /**
     * You need to create custom {@link Mapper} only to take advantage of {@link #resolve(Object)}
     * 
     * @return
     */
    public static Mapper defaultMapper() {
        return defaultMapper;
    }
    
    /**
     * @see #map(Object, Object, boolean)
     */
    public <T> T map(final T to, final Object from) {
        return map(to, from, true);
    }
    
    /**
     * Map one object from another. Iterates thought declared non-static, non-transient fields of destination object and try to initialize them from source
     * using annotation hints if any.
     * 
     * @param to
     *            destination object
     * @param from
     *            source object
     * @param strict
     *            throw runtime exception if some fields cannot be mapped.
     * @return mapped object
     */
    @SuppressWarnings("unchecked")
    public <T> T map(final T to, final Object from, final boolean strict) {
        final Class<?> fromClass = getNonAnonymousClass(from.getClass());
        try {
            verifyContract(to, fromClass);
            for (final AccessibleObject member : contract.get(to.getClass()).get(null).keySet())
                try {
                    final String path = getPath(to.getClass(), fromClass, member);
                    if (path.equals(SKIP_SIGN))
                        continue;
                    final Object value = ReflectUtils.getByPath(from, path);
                    final Class<?> memberType = getMemberType(member);
                    if (value == null)
                        if (nullable.get(member))
                            set(member, to, null);
                        else
                            throw new MapException("Can not set null to non-nullable " + member);
                    else if (Collection.class.isAssignableFrom(value.getClass())) {
                        final Collection<Object> collection = (Collection<Object>) notNull(memberType, get(member, to));
                        final Class<?> collectionsItemType = getCollectionsMemberType(member);
                        for (final Object item : (Collection<?>) value) {
                            final Object resolved = collectionsItemType.getAnnotation(Approximate.class) == null
                                    ? resolve(item)
                                    : map(notNull(collectionsItemType, null), item);
                            collection.add(resolved);
                        }
                        set(member, to, collection);
                    } else {
                        final Object resolved = memberType.getAnnotation(Approximate.class) == null
                                ? resolve(value)
                                : map(notNull(memberType, get(member, to)), value);
                        set(member, to, resolved);
                    }
                } catch (final RuntimeException e) {
                    if (strict)
                        throw new MapException("Can not set " + member, e);
                }
            return to;
        } catch (final Exception e) {
            throw new MapException("Can not map " + to.getClass() + " from " + fromClass, e);
        }
    }
    
    private Class<?> getNonAnonymousClass(final Class<?> clazz) {
        Class<?> nonAnonymousClass = clazz;
        while (nonAnonymousClass.isAnonymousClass())
            nonAnonymousClass = nonAnonymousClass.getSuperclass();
        return nonAnonymousClass;
    }
    
    /**
     * New or existing instance.
     * 
     * @param clazz
     * @param obj
     * @return
     */
    private Object notNull(final Class<?> clazz, final Object obj) {
        if (obj != null)
            return obj;
        else if (List.class.isAssignableFrom(clazz))
            return new LinkedList<Object>();
        else if (Map.class.isAssignableFrom(clazz))
            return new HashMap<Object, Object>();
        else if (Set.class.isAssignableFrom(clazz))
            return new HashSet<Object>();
        else if (clazz.isInterface())
            throw new RuntimeException("Destination member should be declared as variable of class or should be initialized, except for List, Map, Set.");
        else
            return ReflectUtils.newInstance(clazz);
    }
    
    private Class<?> getMemberType(final AccessibleObject member) {
        return member instanceof Field
                ? ((Field) member).getType()
                : ((Method) member).getParameterTypes()[0];
    }
    
    private Class<?> getCollectionsMemberType(final AccessibleObject member) {
        final Approximate approximate = member.getAnnotation(Approximate.class);
        if (approximate != null)
            return approximate.targetEntity();
        return Object.class;
    }
    
    /**
     * Field / Member agnostic reflective setter.
     * 
     * @param member
     *            field or setter
     * @param to
     * @param value
     * @throws IllegalArgumentException
     * @throws IllegalAccessException
     * @throws InvocationTargetException
     */
    private void set(final AccessibleObject member, final Object to, final Object value) throws IllegalArgumentException, IllegalAccessException,
            InvocationTargetException {
        if (member instanceof Field)
            ((Field) member).set(to, value);
        else
            ((Method) member).invoke(to, value);
    }
    
    /**
     * Field / Method agnostic reflective getter.
     * 
     * @param member
     *            field or setter
     * @param from
     * @return
     * @throws IllegalArgumentException
     * @throws IllegalAccessException
     * @throws InvocationTargetException
     */
    private Object get(final AccessibleObject member, final Object from) throws IllegalArgumentException, IllegalAccessException, InvocationTargetException {
        if (!getters.containsKey(member))
            return null;
        final AccessibleObject getter = getters.get(member);
        if (getter instanceof Field)
            return ((Field) getter).get(from);
        return ((Method) getter).invoke(from);
    }
    
    private void verifyContract(final Object to, final Class<?> fromClass) throws SecurityException {
        // usual case
        if (contract.containsKey(to.getClass()) && contract.get(to.getClass()).containsKey(fromClass))
            return;
        
        // process class annotations
        final Approximate approximate = to.getClass().getAnnotation(Approximate.class);
        if (approximate == null)
            throw new IllegalAccessError(to.getClass() + " is not marked as Approximate entity.");
        final boolean isSettersUsedForMapping = asList(approximate.mode()).contains(APPROXIMATE_USING_SETTERS);
        
        synchronized (to.getClass()) {
            Map<Class<?>, Map<AccessibleObject, String>> fromClasses = contract.get(to.getClass());
            final List<AccessibleObject> members = members(to, isSettersUsedForMapping);
            if (fromClasses == null) {
                fromClasses = new HashMap<Class<?>, Map<AccessibleObject, String>>();
                contract.put(to.getClass(), fromClasses);
                fromClasses.put(null, defaults(members));
            }
            if (disjoint(asList(approximate.source()), asList(fromClass.getName(), fromClass.getSimpleName())))
                throw new IllegalAccessError(to.getClass() + " is not declared to be suitable for mapping from " + fromClass);
            final Map<AccessibleObject, String> paths = new HashMap<AccessibleObject, String>();
            fromClasses.put(fromClass, paths);
            for (final AccessibleObject member : members) {
                final Approximate memberApproximate = ((AnnotatedElement) member).getAnnotation(Approximate.class);
                if (memberApproximate == null)
                    continue;
                final List<String> source = asList(memberApproximate.source());
                int classNameIdx = source.indexOf(fromClass.getName());
                if (classNameIdx < 0)
                    classNameIdx = source.indexOf(fromClass.getSimpleName());
                if (classNameIdx < 0)
                    continue;
                paths.put(member, memberApproximate.path()[classNameIdx]);
            }
        }
    }
    
    private Map<AccessibleObject, String> defaults(final List<AccessibleObject> members) throws SecurityException, VerifyError {
        final Map<AccessibleObject, String> defaults = new HashMap<AccessibleObject, String>();
        for (final AccessibleObject member : members) {
            String defaultPath = ((Member) member).getName();
            if (member instanceof Method) {
                defaultPath = defaultPath.replaceFirst("set", "");
                defaultPath = String.valueOf(defaultPath.charAt(0)).toLowerCase() + defaultPath.substring(1);
            }
            defaults.put(member, defaultPath);
            nullable.put(member, true);
            
            // overridden defaults
            final Approximate memberApproximate = ((AnnotatedElement) member).getAnnotation(Approximate.class);
            if (memberApproximate != null) {
                final String[] classes = memberApproximate.source();
                final String[] paths = memberApproximate.path();
                if (paths.length > classes.length) {
                    defaults.put(member, paths[paths.length - 1]);
                    if (classes.length != paths.length - 1)
                        throw new VerifyError("Contract is not respected for source and path.");
                }
                nullable.put(member, memberApproximate.nullable());
            }
            
            final AccessibleObject getter = getCorrespondentGetter(member);
            if (getter != null)
                getters.put(member, getter);
        }
        return defaults;
    }
    
    private List<AccessibleObject> members(final Object to, final boolean isSettersUsedForMapping) throws SecurityException {
        final List<AccessibleObject> result = new ArrayList<AccessibleObject>();
        final AccessibleObject[] members = isSettersUsedForMapping
                ? to.getClass().getDeclaredMethods()
                : to.getClass().getDeclaredFields();
        for (final AccessibleObject member : members) {
            final int memberModifiers = ((Member) member).getModifiers();
            if (Modifier.isStatic(memberModifiers) || member instanceof Field && Modifier.isTransient(memberModifiers))
                continue;
            final Approximate approximate = member.getAnnotation(Approximate.class);
            if (approximate != null && asList(approximate.mode()).contains(TRANSIENT))
                continue;
            if (member instanceof Method && !(((Method) member).getName().startsWith("set") && ((Method) member).getParameterTypes().length == 1))
                continue;
            member.setAccessible(true);
            result.add(member);
        }
        return result;
    }
    
    private AccessibleObject getCorrespondentGetter(final AccessibleObject setter) throws SecurityException {
        if (setter instanceof Field)
            return setter;
        final Method method = (Method) setter;
        final Class<?> declaringClass = method.getDeclaringClass();
        try {
            final Method getter = declaringClass.getDeclaredMethod(method.getName().replaceFirst("s", "g"), new Class<?>[] {});
            getter.setAccessible(true);
            return getter;
        } catch (final NoSuchMethodException e) {
            return null;
        }
    }
    
    private String getPath(final Class<?> toClass, final Class<?> fromClass, final AccessibleObject member) {
        final Map<Class<?>, Map<AccessibleObject, String>> fromClasses = contract.get(toClass);
        String result = null;
        result = fromClasses.get(fromClass).get(member);
        if (result == null)
            result = fromClasses.get(null).get(member);
        if (result == null)
            throw new Error("Defaults is not initialized.");
        return result;
    }
    
    /**
     * Override this method to substitute instances of remote class to instances of local ones.
     * 
     * @param value
     *            instance to be substituted
     * @return substituted instance
     * @see Object#readResolve()
     */
    protected Object resolve(final Object value) {
        return value;
    }
}
